7.2 泛型的使用与实现分析
泛型是Go 1.18
引入的概念,在引入这个概念前经过了好几年的考量最终才将这这个特性加进去。
泛型在多种语言中都是存在的,比如C++
、Java
等语言中都有泛型的概念。
本节我们将针对泛型的使用、实现原理进行整体的讲解。
本节代码存放目录为 lesson20
泛型基础
什么是泛型?
简单来说,泛型与空接口interface{}
相似但又有不同。我们知道空接口可以用来标识任意的类型,其实泛型也是干这件事情的。
那么既然有了接口,为什么还要出现泛型呢?这需要结合我们之前章节的反射来一起看待。
在我们使用interface{}
与反射来进行处理时,其实都是在运行时处理,运行时处理那么不可避免的就会出现性能开销与安全性问题。
而泛型则是在编译阶段进行处理的,而不是运行时处理,所以不管是从性能还是安全性来说,泛型都是一种更好的选择。
另外泛型主要用于函数与类型的的定义,而不能用于普通变量的定义,这也是与interface{}
的主要区别。
泛型的主要应用是在函数的定义上。当我们有一些公用函数,比如说:打印、求和等,在没有泛型的时候,我们需要定义一个参数为int
型的Print
、一个参数为string
型的Print
,但是如果是泛型的话我们只需要定义一个即可。
泛型函数的实现与应用
简单泛型函数
func Print[T any](input T) { fmt.Println(input) } Print(1) Print("hello")
在上面的代码中,我们通过泛型仅定义了一个函数,即实现了传递
int
、string
参数的目的。定义的格式也是固定的:
func funcName[T any](arg T)
,其中[T any]
就标识这个函数是一个泛型函数。多个类型参数
func Add[T int | float64](a, b T) T { return a + b } fmt.Println(Add(1, 2)) fmt.Println(Add(1.5, 2.3))
在上面的代码中,我们传入了多个参数,同时我们可以看到,使用了
[T int | float64]
这样的格式。那么这种格式是什么意思呢?如果我们这样写,其实就代表这个函数接收的参数只能是
int
、float64
两种类型的。基于我们函数的功能,如果传入
string
、结构体
等类型的参数,那么肯定是不符合的,所以我们可以在函数中就指定好传入的类型范围。
泛型结构体
在结构体中使用泛型也是比较常用的一个操作,比如我们的结构体字段是相同的,但是会接收不同类型的值,那么使用泛型也是一个很好的选择。
type Container[T any] struct {
value T
}
intContainer := Container[int]{value: 42}
fmt.Println(intContainer.value)
stringContainer := Container[string]{value: "hello"}
fmt.Println(stringContainer.value)
结构体泛型使用会比较广泛,特别是在一些算法或数据结构类型的场景。比如说实现一个栈、一个队列,那么我们就可以使用泛型来实现,这样栈就可以存储多种数据类型。
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
n := len(s.items)
item := s.items[n-1]
s.items = s.items[:n-1]
return item
}
// 创建一个整数栈
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
fmt.Println(intStack.Pop())
// 创建一个字符串栈
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println(stringStack.Pop())
通过泛型我们就可以简单的进行处理,这种方法其实是比interface{}
高效很多的。
泛型的约束与接口
基本泛型约束
// Compare 约束 T 必须可比较(类型必须实现了comparable接口) func Compare[T comparable](a, b T) bool { return a == b } fmt.Println(Compare(1, 2)) fmt.Println(Compare("Go", "Go"))
在上面的代码中,
T
类型参数使用了comparable
作为约束,表示T
必须是支持比较操作的类型,例如整数、字符串等。comparable
是Go
的内置接口,用于表示可以比较的类型(支持==
和!=
操作)。自定义接口作为约束
// Stringer 定义一个接口 type Stringer interface { String() string } // PrintString 泛型函数,T 必须实现 Stringer 接口 func PrintString[T Stringer](item T) { fmt.Println(item.String()) } // Person 实现 Stringer 接口的类型 type Person struct { Name string } func (p Person) String() string { return p.Name }
在这个例子中,泛型函数
PrintString
限定类型参数T
必须实现Stringer
接口。也就是说
PrintString
只能用于那些实现了Stringer
接口的类型,比如Person
。
内置的泛型约束
// Number 泛型约束 T 必须是 int 类型的别名 type Number interface { ~int } func Sum[T Number](a, b T) T { return a + b } type MyInt int // MyInt 是 int 的别名 var a MyInt = 10 var b MyInt = 20 fmt.Println(Sum(a, b))
在上面的代码中,
~int
表示类型参数T
可以是int
或任何int
的别名类型(如MyInt
)。
实现原理
如果了解Java
的话我们可以知道,Java
只要函数参数的类型不同,那么函数名称可以是相同的。
在Go
语言中,泛型其实差不多就是这么实现的。在编译的时候,编译器会生成多个类型的函数。
如下代码所示:
func Print[T any](input T) {
fmt.Println(input)
}
Print(1)
Print("hello")
在上面的代码中,我们实现了一个简单的打印函数,调用的时候传入了int
与string
类型的数据。
那么在我们编译的时候,编译器可能会生成下面的代码:
func Print1[int](input int) {
fmt.Println(input)
}
func Print2[string](input string) {
fmt.Println(input)
}
执行的大概示意图如下所示:
泛型函数 Print[T]
+-----------------+
| 泛型代码 |
+-----------------+
|
Monomorphization
(为不同值类型生成副本)
|
+------------+
| PrintInt() | // 为 int 类型生成的函数副本
+------------+
| PrintStr() | // 为 string 类型生成的函数副本
+------------+
| PrintF64() | // 为 float64 类型生成的函数副本
+------------+
Go
的泛型使用了多种方式,上面描述的属于其中的一种方式,也就是单态化,这种方式主要应用于参数是值的函数。
上面我们提到的这种方式虽然简单,但是如果函数副本太多的话,最终编译出来的二进制文件肯定是很大的,所以还采用了虚拟方法表的方式。
当泛型函数接收的是指针类型或接口类型时,编译器会为它生成一个字典表。这个表类似于虚拟方法表,记录了如何在运行时处理不同类型的操作。
我们可以通过下面的示意图来理解:
编译时:
+--------------------------------------------+
| 编译器检查到 Person 实现了 Stringer 接口 |
+--------------------------------------------+
|
v
+-----------------------------------------+
| 生成 Person 的虚拟方法表(VMT) |
| 包含 String() 指向 Person.String 的指针 |
+-----------------------------------------+
运行时:
+-------------------------------------+
| 调用 PrintString(p) |
+-------------------------------------+
|
v
+---------------------------+
| 查找 p 的虚拟方法表 | --> 找到 Person.String() 方法
+---------------------------+
|
v
+----------------------+
| 调用 Person.String() |
+----------------------+
虚拟方法表比较抽象,我们以一句话理解就可以:调用的时候,会去查找PrintString(p)
中p
的方法表,最终找到了Person.String()
,这时候就直接执行就可以了。
Go
语言的泛型实现还在持续优化中,我们可以持续关注,现阶段掌握泛型的使用即可。
小结
本节我们讲解了泛型的基础概念、使用以及简单的实现原理。泛型为Go
语言带来了更大的灵活性,帮助开发者编写更具通用性的代码。
在框架开发、工具开发场景应用比较广泛,通过泛型我们可以简单的将代码合并优化。